Skip to content

docs(self-hosting): name the measured recovery for a directory-seeded, credential-less deployment - #15586

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-14495-self-hosting-bootstrap-recovery
Sep 4, 2026
Merged

docs(self-hosting): name the measured recovery for a directory-seeded, credential-less deployment#15586
baozhoutao merged 2 commits into
mainfrom
claude/issue-14495-self-hosting-bootstrap-recovery

Conversation

@claude

@claude claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #14495

content/docs/deployment/self-hosting.mdx told an operator on a walled posture
to "have each of those people register with exactly that address". On a
deployment whose people directory was seeded without credentials that
registration is the door invite_only shuts, so the published recovery path
was the one that is closed.

Docs-only. No image-tag version rows touched, nothing under
content/docs/releases/.

The measurement (acceptance 1) — done first, not assumed

Real kernel: ObjectQL over @objectstack/driver-sql + better-sqlite3
:memory:, the auth plugin's own authIdentityObjects, driven through
AuthManager.handleRequest (the HTTP lane) — the harness shape
audience-bootstrap-seam.test.ts uses, because the in-memory doubles cannot
answer this (#4419 requireFindOnePredicate). Population: three human
sys_user rows, zero sys_account rows, NODE_ENV=test (not development,
so the dev-admin seed is disarmed by isDevAdminSeedArmed()), default
invite_only audience posture.

Baseline — the dead end, reproduced:

[14495] sys_user human rows   ["alice@corp.example","bob@corp.example","carol@corp.example"]
[14495] sys_account rows      0
[14495] probeSignInReachability  {"humanUsers":"present","signInAccounts":"absent"}
[14495] sign-in as a seeded person
        {"status":401,"code":"INVALID_EMAIL_OR_PASSWORD"}
[14495] THE PUBLISHED INSTRUCTION - seeded person registers
        {"status":403,"code":"SELF_REGISTRATION_CLOSED"}
[14495] a fresh address registers
        {"status":403,"code":"SELF_REGISTRATION_CLOSED"}
[14495] sys_account rows after all three   0

The boot diagnostic from #14353 (CLOSED — landed as
packages/plugins/plugin-auth/src/boot-sign-in-reachability.ts) fires on
exactly this population, under the grep token no_sign_in_account_at_boot,
at error level; the page now points at it.

The working path (this is what the page now documents). Write one pending
sys_invitation row against the store — email (an address the directory does
NOT already hold), status: 'pending', a future expires_at, inviter_id of
any existing sys_user row — and have that person register through the
ordinary sign-up endpoint. The invitation carve-out admits that one creation
under every posture, so the invite_only default is never widened:

[14495] invite_only + invitation - FRESH address registers
        {"status":200, token issued}
[14495] invite_only + invitation - sign-in with the new address
        {"status":200}                       <- FIRST LOGIN EXISTS

…and the recovered account is the one first-boot promotion finds, on the
single posture — measured by running bootstrapPlatformAdmin over the same
engine afterwards:

[14495] bootstrapPlatformAdmin verdict
        {"seeded":8,"adminPromoted":true,"ownershipClaimed":0}
[14495] sys_user_permission_set rows
        [["3ddRidNEqTN462AueSnqSaX8jM4wz08W","ps_mtnibyhur267cdly"]]

i.e. the grant lands on the invited account holder, never on a
credential-less directory row (#14348's selector).

Two remedies the boot diagnostic names that do NOT produce a login on their
own — measured, and documented on the page as failures:

[14495] posture=email_domain - seeded address registers - HTTP verdict
        {"status":200, user id "HEfl4PyZmNaP2F4zjfawppU3R9IhsO9t"}
[14495] rows carrying that address afterwards   [["usr_alice",false]]   <- no new row
[14495] accounts afterwards                     []                      <- no account
[14495] sign-in as that seeded person afterwards
        {"status":401,"code":"INVALID_EMAIL_OR_PASSWORD"}

[14495] posture=email_domain - FRESH address registers   {"status":200}
[14495] posture=email_domain - sign-in with the new address
        {"status":403,"code":"EMAIL_NOT_VERIFIED"}   <- every posture but invite_only forces verification

[14495] hand-written credential row (plaintext password) - sign-in
        {"status":401,"code":"INVALID_EMAIL_OR_PASSWORD"}
[14495] boot diagnostic after that write
        {"humanUsers":"present","signInAccounts":"present"}   <- the diagnostic goes SILENT

Two further facts the page states:

  • a sign-up for an address the directory already holds is refused
    422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL (invitation or not), so the
    invited address must be a new one;
  • on a walled posture the invited address must ALSO be declared in
    OS_PLATFORM_OWNER_EMAIL and read verified — measured,
    shouldStampOwnerVerifiedAtCreation answers false for an
    invitation-admitted (self-serve, non-bootstrap) creation and true only for
    a bootstrap or operator-class one, so that address needs a mailbox.

Acceptance 3 (stop rule) does not fire: a working path exists and is measured,
so premise_still_valid is true.

What changed on the page (acceptance 2)

  • the single paragraph now says what fresh means — zero human sys_user
    rows, not merely zero logins — and links to the new subsection;
  • the walled paragraph keeps its instruction but scopes it to a still-empty
    directory and points at the out-of-band recovery;
  • new subsection "A seeded directory opens no bootstrap window": the
    measured refusal table, the boot diagnostic and its grep token, the
    invitation row that recovers the deployment, the two measured non-remedies,
    and the prevention (seed logins with the directory);
  • the dev-admin seed is now described as NODE_ENV=development-gated —
    a development convenience, never a production recovery path.

Verification

Gate family derived, not guessed:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
— re-derived after merging origin/main so the answer is not from a stale
tree; change set 1 path(s) vs merge base 3d3f60e01, exactly
content/docs/deployment/self-hosting.mdx. All 38 derived commands run,
each exit code captured before any pipe; 38/38 exit 0. Verdict lines the
gates printed:

✅ check-doc-anchors: 307 internal #fragment link(s) across 410 source file(s) all resolve to a real heading
✓ check-docs-single-h1: 405 page(s) under content/docs/ carry no body-level `# ` heading (0 subtree(s) excluded, see --list).
✓ content/docs (docsSchema, floor 1): 405 page(s) parse with yaml@2.9.0, the parser the docs build resolves — 190 hand-written + 215 generated under references/.
✓ doc authoring guard: 46 published skill files clean — no internal issue-id references.
✓ release-owned pages are in scope and read-only: 9 page(s) under content/docs/releases/ review-only (findings → issues, never edited).
check-docs-image-tag: OK (3/3 enumerated surface(s) read, 9 concrete pin(s) compared against packages/cli/package.json 17.3.0, ...)
check-nul-bytes: OK (scanned 7555 text file(s) -- 7555 tracked, 0 untracked-not-ignored; skipped 7 binary; no raw ASCII control bytes).
✅ 230 generated files in sync with packages/spec            (check:docs)
✅ 257 prose examples type-check across 3 surface(s)          (check:skill-examples)

Two gates first answered PREREQUISITE NOT MET rather than a verdict —
check:docs ("packages/spec/json-schema is older than packages/spec/src") and
check:skill-examples (spec .d.ts stale, then
"packages/client-react/dist holds no .d.ts declarations"). Those are NOT
MEASURED, never a pass: pnpm --filter @objectstack/spec build and
pnpm --filter @objectstack/client-react... build were run (both
os-verify-lock: VERDICT command-exit 0) and both gates re-run to the green
verdict lines quoted above.

Heavy runs went through the shared entry point
(bash scripts/pm/os-verify-lock.sh -c …, slot issue-14495-dev); the
measurement itself ended os-verify-lock: VERDICT command-exit 0 · held the lock 16s with Tests 6 passed (6).

Repo-wide pnpm lint (eslint . --no-inline-config) is CI's run, and this
diff is provably outside its population — a declared narrowing, with its three
pieces: (1) eslint's own config resolution answers, for the only changed file,
File ignored because no matching configuration was supplied; (2)
--format json returns 1 result, 0 errors; (3) the diff adds no eslint config
and no JS/TS source, so no untouched file's verdict can move.

Every figure above was measured on the branch's final tree,
git rev-parse --short HEAD = 472e204e1.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

… deployment

The page told an operator on a walled posture to "have each of those people
register with exactly that address". Measured on a real kernel (three human
`sys_user` rows, zero `sys_account` rows, outside development, default
`invite_only` audience posture), that registration is refused
`403 SELF_REGISTRATION_CLOSED`: the first-account carve-out counts humans, not
logins, and humans already exist. So the published recovery path was the one
that is shut, and the deployment cannot be recovered from inside.

Both first-boot paths now say what "fresh" means, and a new subsection states
the dead end, points at the boot diagnostic (`no_sign_in_account_at_boot`),
and names the measured way out: write one pending `sys_invitation` row against
the store for an address the directory does not already hold, then have that
person register normally — measured sign-up 200 and sign-in 200 with no mail
transport, and on `single` the next boot promotes that account holder
(`adminPromoted: true`) rather than a credential-less directory row.

Two plausible-looking remedies are documented as measured failures: opening the
audience posture answers 200 while persisting nothing for an address the
directory already holds, and forces email verification on every other one; and
a hand-written `sys_account` row with a plaintext password authenticates
nothing while silencing the boot diagnostic.

The dev-admin seed is described as development-only, as it is gated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 4, 2026
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation labels Sep 4, 2026
@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — PM seat domain:devx @ objectstack (#6023), session session_012zGPuVVX3deAx9LdjK8jCk.

Reviewed against the three-dot diff origin/main...claude/issue-14495-self-hosting-bootstrap-recovery (head 472e204e1, a merge of origin/main over 1ee3c1631; one file, content/docs/deployment/self-hosting.mdx, +94/−8), not the self-report. The card's stop rule ("no bootstrap path") did not fire: a working out-of-band path was measured, so the page documents it.

What I checked in the diff:

  • The single paragraph now defines fresh as zero human sys_user rows (not zero logins) and links the new subsection; the walled-posture paragraph states its registration is admitted by the first-account carve-out and therefore works only on an empty directory; the dev-seed note states the NODE_ENV=development gate.
  • New subsection "A seeded directory opens no bootstrap window": the measured outcome table (403 / 403 / 401 on 3 human rows, 0 accounts, default invite_only), the boot diagnostic's grep token no_sign_in_account_at_boot with its blind spot (any sys_account row silences it), the one-row sys_invitation recovery (column table), the measured result (sign-up 200 / sign-in 200, adminPromoted: true on single, walled postures need OS_PLATFORM_OWNER_EMAIL + verification), the 422 for an already-held address, and the two measured non-remedies (posture widening persists nothing / a hand-written credential row authenticates nothing). Prevention stated last.
  • Nothing under content/docs/releases/, no image-tag rows; the anchor #a-seeded-directory-opens-no-bootstrap-window resolves (dev's check-doc-anchors: 307 links resolve).

Dev's verification quoted: measurement harness Tests 6 passed (6) on a real kernel (ObjectQL + driver-sql + better-sqlite3, plugin-auth's authIdentityObjects, AuthManager.handleRequest), harness deleted before commit; derived family 38 commands, 38/38 exit 0 on 472e204e1 after re-deriving on the merged tree (check-doc-anchors 307, check-docs-single-h1 405, check-docs-image-tag 9 pins vs 17.3.0, check:docs 230 files in sync, check:skill-examples 257); two gates first PREREQUISITE NOT MET, built, then green. Not re-measured by this seat (needs built packages); CI's docs gates are the arbiter. Repo-wide lint declared-narrowed with its three pieces (the only changed file is ignored by eslint's config).

Findings filed by the dev, bare for triage: #15587 (sign-up for an already-held address answers 200 and persists nothing under email_domain) and #15588 (both remedies the boot report names are unexecutable as written, and the second silences it).

Flipping ready + enabling auto-merge. Fixes #14495.


Generated by Claude Code

@baozhoutao
baozhoutao marked this pull request as ready for review September 4, 2026 22:32
@baozhoutao
baozhoutao enabled auto-merge September 4, 2026 22:32
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit f1d7872 Sep 4, 2026
37 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-14495-self-hosting-bootstrap-recovery branch September 4, 2026 23:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants